Repository navigation
Conversation
ctc_limiting_tax_liability evaluated income_tax_before_credits on a "no_salt" branch. get_branch clones every array the parent has cached and set_input on the branch invalidates nothing derived from salt_deduction, so the result depended on request order: requesting income_tax first returned actual liability, while requesting something that reaches refundable_ctc first (medicaid, household_net_income, ...; policyengine.py requests medicaid first) returned liability with neither SALT nor the standard deduction. For a liability-limited SALT itemizer that swung refundable_ctc and income_tax by up to $3,400. The branch is no longer needed to avoid a cycle: the SALT deduction's income tax component is state_withheld_income_tax (AGI-based) plus local_income_tax, neither of which reads the federal CTC. Use actual income_tax_before_credits, which is the section 26(a) limitation. Adds an order-independence example test, a Hypothesis property test of order independence and the section 26(a) / ctc_value identities, a YAML case, and hypothesis to the dev extra. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9649 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 4 1 -3
Lines 76 15 -61
Branches 2 0 -2
=========================================
- Hits 76 15 -61
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Closing as superseded by #9741, merged 2026-10-06 as 9dc167b, on the US + core hub's PR sweep. #9741 reapplies this PR's fix (CTC Worksheet A line 1 read from actual liability) on top of the Worksheet A/B split that #9742 introduced, which made this branch's direct patch stale. It also corrects the Oklahoma test this branch failed: |
Summary
ctc_limiting_tax_liabilitygave different answers depending on which variable a caller requested first. For SALT itemizers whose CTC is limited by tax liability, that changedrefundable_ctc,ctc_valueandincome_tax. This PR computes the limit from actualincome_tax_before_credits, which includes the SALT deduction. That is what 26 USC 26(a) and Schedule 8812 use, and the result no longer depends on request order.Reproduction
A married couple in New York with one earner ($50,000 wages), two children and $40,000 of property tax, 2026. The only difference between the columns is the first variable requested from a fresh
Simulation:income_taxfirstctc_limiting_tax_liabilityfirstThis reproduces on policyengine-us 2.2.1 / policyengine-core 3.32.5 and on 2.15.1 / 3.32.7. The law gives the left column: liability after the $40,400 SALT deduction is $960, so the refundable portion is min($4,400 − $960, 2 × $1,700, 15% × ($50,000 − $2,500)) = $3,400. In the right column,
ctc_value($4,400) also disagrees with the creditincome_taxactually applies ($960).Repro script
Root cause
income_tax_before_creditsonsimulation.get_branch("no_salt")afterset_input("salt_deduction", 0).get_branchclones the parent throughHolder.clone, which copies_memory_storage, so the branch inherits every array the parent has already cached.set_inputon the branch replaces onlysalt_deductionand invalidates nothing derived from it. The two outcomes:income_taxrequested first): the branch returned the parent's SALT-inclusive value, and the override did nothing.tax_unit_itemizeson the branch, an itemizer then lost both the SALT deduction and the standard deduction. That gives $5,504 above, where SALT was the only itemized deduction.medicaid,household_net_income,spm_unit_net_income,refundable_ctc,ctc_valueornon_refundable_ctcfirst lands in the second case.medicaidgets there throughmedicaid_uses_non_filer_rules → tax_unit_is_filer → eligible_for_refundable_credits → refundable_ctc. policyengine.py 6.1.1 requestsmedicaidbeforeincome_tax, so policyengine.py and a plainMicrosimulationdisagree.Why no branch is needed now
The documentation said excluding SALT was "required to avoid circular dependencies". That no longer holds:
state_and_local_sales_or_income_taxisstate_withheld_income_taxpluslocal_income_tax. All 42*_withheld_income_taxformulas estimate withholding fromadjusted_gross_income_person.CycleErrorin any test here, or on all 57,240 households ofpopulace_us_2024run in both request orders (below).Impact
On
populace_us_2024for 2026 (policyengine-us 2.2.1, 79,729 tax units), compared per tax unit between policyengine.py's request order andincome_taxfirst:ctc_limiting_tax_liabilitydiffers for 8,643 tax units (17.0M weighted). All are SALT itemizers.income_taxfirst. The whole $75.6M gap is this bug.income_taxis identical, to $0.00, in policyengine.py's request order and withincome_taxfirst, in baseline and reform. The values equal the oldincome_tax-first results exactly. On those units the old code had 275 differing limits and 10 differing income taxes. Nothing raised aCycleError.Callers that already requested
income_taxfirst see no change. Callers whose first request reachedrefundable_ctcget the SALT-inclusive limit.Invariants (tested)
ctc_limiting_tax_liability,refundable_ctc,non_refundable_ctc,ctc_value,income_tax_before_credits,ctcandincome_taxare identical whichever variable is requested first.ctc_limiting_tax_liability == max(0, income_tax_before_credits − other non-refundable credits).ctc_value == min(non_refundable_ctc, that liability) + refundable_ctc. The reported CTC equals the CTC that income tax actually delivers.Tests
policyengine_us/tests/test_ctc_limiting_tax_liability_order.py, with two tests:ctc_limiting_tax_liability.yaml, listingctc_limiting_tax_liabilityfirst. It returned 5,504 before this change.test_ctc_itemizing_branch_cycle.py(refundable_ctc ↔ income_tax dependency cycle surfaces on itemizing branch #8059) still passes; I updated its docstring.hypothesisto thedevextra;uv.lockgains onlyhypothesis.Local results on policyengine-us 2.15.1 (this branch) with policyengine-core 3.32.7:
pytest policyengine_us/tests/test_ctc_limiting_tax_liability_order.py policyengine_us/tests/test_ctc_itemizing_branch_cycle.py: 11 passed.policyengine-core test policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ -c policyengine_us: 32 passed.ctc_limiting_tax_liability5,504 instead of 960. The YAML case fails withctc_limiting_tax_liability@2026: [5504.] differs from 960.0.policyengine_us/tests/policy/baseline/gov/irs/.axiom: TheAxiomFoundation/rulespec-us#1426 queued
rulespec-us's section 24(d) takes the section 26(a) credit aggregates as caller inputs, and
26/b#regular_tax_liabilityis deferred, so no module decides which liability limits the CTC. The composition bridge in axiom-oracles already uses SALT-inclusiveincome_tax_before_credits. #1426 is a dispatch-ready encoding request: the verbatim law, a pasteablereview_finding, and six companion cases computed from the statute and Rev. Proc. 2025-32, including this PR's household. It is sequenced after rulespec-us#1395.🤖 Generated with Claude Code